/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

/// <reference types="google.maps" />

import type { LoaderOptions } from '@googlemaps/js-api-loader';
import { Loader } from '@googlemaps/js-api-loader';
import type {
    CircleDefinition,
    Icon,
    InfoWindowDefinition,
    MapDefinition,
    MarkerDefinition,
    PolygonDefinition,
    PolylineDefinition,
    RectangleDefinition,
} from '@symfony/ux-map';
import AbstractMapController, { IconTypes } from '@symfony/ux-map';

type MapOptions = Pick<
    google.maps.MapOptions,
    | 'mapId'
    | 'gestureHandling'
    | 'backgroundColor'
    | 'disableDoubleClickZoom'
    | 'zoomControl'
    | 'zoomControlOptions'
    | 'mapTypeControl'
    | 'mapTypeControlOptions'
    | 'streetViewControl'
    | 'streetViewControlOptions'
    | 'fullscreenControl'
    | 'fullscreenControlOptions'
>;

let _google: typeof google;

// Loading the Google Maps API is an asynchronous operation, so we need to track the loading state to prevent race conditions.
let _loading = false;
let _loaded = false;
let _onLoadedCallbacks: Array<() => void> = [];

const parser = new DOMParser();

export default class extends AbstractMapController<
    MapOptions,
    google.maps.MapOptions,
    google.maps.Map,
    google.maps.marker.AdvancedMarkerElementOptions,
    google.maps.marker.AdvancedMarkerElement,
    google.maps.InfoWindowOptions,
    google.maps.InfoWindow,
    google.maps.PolygonOptions,
    google.maps.Polygon,
    google.maps.PolylineOptions,
    google.maps.Polyline,
    google.maps.CircleOptions,
    google.maps.Circle,
    google.maps.RectangleOptions,
    google.maps.Rectangle
> {
    declare providerOptionsValue: Pick<LoaderOptions, 'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version' | 'libraries'>;

    declare map: google.maps.Map;

    async connect() {
        const onLoaded = () => super.connect();

        if (_loaded) {
            onLoaded();
            return;
        }

        if (_loading) {
            _onLoadedCallbacks.push(onLoaded);
            return;
        }

        _loading = true;
        _google = { maps: {} as typeof google.maps };

        let { libraries = [], ...loaderOptions } = this.providerOptionsValue;

        const loader = new Loader(loaderOptions);

        // We could have used `loader.load()` to correctly load libraries, but this method is deprecated in favor of `loader.importLibrary()`.
        // But `loader.importLibrary()` is not a 1-1 replacement for `loader.load()`, we need to re-build the `google.maps` object ourselves,
        // see https://github.com/googlemaps/js-api-loader/issues/837 for more information.
        libraries = ['core', ...libraries.filter((library) => library !== 'core')]; // Ensure 'core' is loaded first
        const librariesImplementations = await Promise.all(libraries.map((library) => loader.importLibrary(library)));
        librariesImplementations.map((libraryImplementation, index) => {
            if (typeof libraryImplementation !== 'object' || libraryImplementation === null) {
                return;
            }

            const library = libraries[index];

            // The following libraries are in a sub-namespace
            if (['marker', 'places', 'geometry', 'journeySharing', 'drawing', 'visualization'].includes(library)) {
                // @ts-ignore
                _google.maps[library] = libraryImplementation as any;
            } else {
                _google.maps = { ..._google.maps, ...libraryImplementation };
            }
        });

        _loading = false;
        _loaded = true;
        onLoaded();
        _onLoadedCallbacks.forEach((callback) => callback());
        _onLoadedCallbacks = [];
    }

    public centerValueChanged(): void {
        if (this.map && this.hasCenterValue && this.centerValue) {
            this.map.setCenter(this.centerValue);
        }
    }

    public zoomValueChanged(): void {
        if (this.map && this.hasZoomValue && this.zoomValue) {
            this.map.setZoom(this.zoomValue);
        }
    }

    public minZoomValueChanged(): void {
        if (this.map && this.hasMinZoomValue && this.minZoomValue) {
            this.map.setOptions({ minZoom: this.minZoomValue });
        }
    }

    public maxZoomValueChanged(): void {
        if (this.map && this.hasMaxZoomValue && this.maxZoomValue) {
            this.map.setOptions({ maxZoom: this.maxZoomValue });
        }
    }

    protected dispatchEvent(name: string, payload: Record<string, unknown> = {}): void {
        payload.google = _google;
        this.dispatch(name, {
            prefix: 'ux:map',
            detail: payload,
        });
    }

    protected doCreateMap({ definition }: { definition: MapDefinition<MapOptions, google.maps.MapOptions> }): google.maps.Map {
        const { center, zoom, minZoom, maxZoom, options, bridgeOptions = {} } = definition;

        // We assume the following control options are enabled if their options are set
        options.zoomControl = typeof options.zoomControlOptions !== 'undefined';
        options.mapTypeControl = typeof options.mapTypeControlOptions !== 'undefined';
        options.streetViewControl = typeof options.streetViewControlOptions !== 'undefined';
        options.fullscreenControl = typeof options.fullscreenControlOptions !== 'undefined';

        return new _google.maps.Map(this.element, {
            center,
            zoom,
            minZoom,
            maxZoom,
            ...options,
            ...bridgeOptions,
        });
    }

    protected doCreateMarker({
        definition,
    }: {
        definition: MarkerDefinition<google.maps.marker.AdvancedMarkerElementOptions, google.maps.InfoWindowOptions>;
    }): google.maps.marker.AdvancedMarkerElement {
        const { '@id': _id, position, title, infoWindow, icon, rawOptions = {}, bridgeOptions = {} } = definition;

        const marker = new _google.maps.marker.AdvancedMarkerElement({
            position,
            title,
            map: this.map,
            ...rawOptions,
            ...bridgeOptions,
        });

        if (infoWindow) {
            this.createInfoWindow({ definition: infoWindow, element: marker });
        }

        if (icon) {
            if (Object.prototype.hasOwnProperty.call(bridgeOptions, 'content')) {
                console.warn('[Symfony UX Map] Defining "bridgeOptions.content" for a marker with a custom icon is not supported and will be ignored.');
            } else if (Object.prototype.hasOwnProperty.call(rawOptions, 'content')) {
                console.warn('[Symfony UX Map] Defining "rawOptions.content" for a marker with a custom icon is not supported and will be ignored.');
            }

            this.doCreateIcon({ definition: icon, element: marker });
        }

        return marker;
    }

    protected doRemoveMarker(marker: google.maps.marker.AdvancedMarkerElement): void {
        marker.map = null;
    }

    protected doCreatePolygon({
        definition,
    }: {
        definition: PolygonDefinition<google.maps.PolygonOptions, google.maps.InfoWindowOptions>;
    }): google.maps.Polygon {
        const { '@id': _id, points, title, infoWindow, rawOptions = {}, bridgeOptions = {} } = definition;

        const polygon = new _google.maps.Polygon({
            paths: points,
            map: this.map,
            ...rawOptions,
            ...bridgeOptions,
        });

        /**
         * @deprecated since Symfony UX Map 2.29, will be removed in 3.0
         */
        if (title) {
            polygon.set('title', title);
        }

        if (infoWindow) {
            this.createInfoWindow({ definition: infoWindow, element: polygon });
        }

        return polygon;
    }

    protected doRemovePolygon(polygon: google.maps.Polygon) {
        polygon.setMap(null);
    }

    protected doCreatePolyline({
        definition,
    }: {
        definition: PolylineDefinition<google.maps.PolylineOptions, google.maps.InfoWindowOptions>;
    }): google.maps.Polyline {
        const { '@id': _id, points, title, infoWindow, rawOptions = {}, bridgeOptions = {} } = definition;

        const polyline = new _google.maps.Polyline({
            path: points,
            map: this.map,
            ...rawOptions,
            ...bridgeOptions,
        });

        /**
         * @deprecated since Symfony UX Map 2.29, will be removed in 3.0
         */
        if (title) {
            polyline.set('title', title);
        }

        if (infoWindow) {
            this.createInfoWindow({ definition: infoWindow, element: polyline });
        }

        return polyline;
    }

    protected doRemovePolyline(polyline: google.maps.Polyline): void {
        polyline.setMap(null);
    }

    protected doCreateCircle({ definition }: { definition: CircleDefinition<google.maps.CircleOptions, google.maps.InfoWindowOptions> }): google.maps.Circle {
        const { '@id': _id, center, radius, title, infoWindow, rawOptions = {}, bridgeOptions = {} } = definition;

        const circle = new _google.maps.Circle({
            center,
            radius,
            map: this.map,
            ...rawOptions,
            ...bridgeOptions,
        });

        /**
         * @deprecated since Symfony UX Map 2.29, will be removed in 3.0
         */
        if (title) {
            circle.set('title', title);
        }

        if (infoWindow) {
            this.createInfoWindow({ definition: infoWindow, element: circle });
        }

        return circle;
    }

    protected doRemoveCircle(circle: google.maps.Circle): void {
        circle.setMap(null);
    }

    protected doCreateRectangle({
        definition,
    }: {
        definition: RectangleDefinition<google.maps.RectangleOptions, google.maps.InfoWindowOptions>;
    }): google.maps.Rectangle {
        const { northEast, southWest, title, infoWindow, rawOptions = {}, bridgeOptions = {} } = definition;

        const rectangle = new _google.maps.Rectangle({
            bounds: new _google.maps.LatLngBounds(southWest, northEast),
            map: this.map,
            ...rawOptions,
            ...bridgeOptions,
        });

        /**
         * @deprecated since Symfony UX Map 2.29, will be removed in 3.0
         */
        if (title) {
            rectangle.set('title', title);
        }

        if (infoWindow) {
            this.createInfoWindow({ definition: infoWindow, element: rectangle });
        }

        return rectangle;
    }

    protected doRemoveRectangle(rectangle: google.maps.Rectangle): void {
        rectangle.setMap(null);
    }

    protected doCreateInfoWindow({
        definition,
        element,
    }: {
        definition: Omit<InfoWindowDefinition<google.maps.InfoWindowOptions>, 'position'>;
        element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon | google.maps.Polyline | google.maps.Circle | google.maps.Rectangle;
    }): google.maps.InfoWindow {
        const { headerContent, content, opened, autoClose, rawOptions = {}, bridgeOptions = {} } = definition;

        let position: google.maps.LatLng | null = null;
        if (element instanceof google.maps.Circle) {
            position = element.getCenter();
        } else if (element instanceof google.maps.Rectangle) {
            position = element.getBounds()?.getCenter() || null;
        } else if (element instanceof google.maps.Polygon || element instanceof google.maps.Polyline) {
            // Note: We do not compute the center of Polygons or Polylines here, since shapes can be complex.
            // Instead, listen to the `ux:map:polygon:before-create` or `ux:map:polyline:before-create` events to set the position manually.
            // ```js
            // const bounds = new google.maps.LatLngBounds();
            // element.getPath().forEach((latLng) => bounds.extend(latLng));
            // event.definition.infoWindow.bridgeOptions.position = bounds.getCenter();
            // ```
        }

        const infoWindowOptions: google.maps.InfoWindowOptions = {
            headerContent: this.createTextOrElement(headerContent),
            content: this.createTextOrElement(content),
            position,
            ...rawOptions,
            ...bridgeOptions,
        };

        const infoWindow = new _google.maps.InfoWindow(infoWindowOptions);

        element.addListener('click', (event: google.maps.MapMouseEvent) => {
            if (autoClose) {
                this.closeInfoWindowsExcept(infoWindow);
            }

            // Don't override the position if it was already set (e.g. through "rawOptions")
            if (infoWindowOptions.position === null) {
                infoWindow.setPosition(event.latLng);
            }

            infoWindow.open({ map: this.map, anchor: element });
        });

        if (opened) {
            if (autoClose) {
                this.closeInfoWindowsExcept(infoWindow);
            }

            infoWindow.open({ map: this.map, anchor: element });
        }

        return infoWindow;
    }

    protected doFitBoundsToMarkers(): void {
        if (this.markers.size === 0) {
            return;
        }

        const bounds = new google.maps.LatLngBounds();
        this.markers.forEach((marker) => {
            if (!marker.position) {
                return;
            }

            bounds.extend(marker.position);
        });

        this.map.fitBounds(bounds);
    }

    private createTextOrElement(content: string | null): string | HTMLElement | null {
        if (!content) {
            return null;
        }

        // we assume it's HTML if it includes "<"
        if (content.includes('<')) {
            const div = document.createElement('div');
            div.innerHTML = content;
            return div;
        }

        return content;
    }

    protected doCreateIcon({ definition, element }: { definition: Icon; element: google.maps.marker.AdvancedMarkerElement }): void {
        const { type, width, height } = definition;

        if (type === IconTypes.Svg) {
            element.content = parser.parseFromString(definition.html, 'image/svg+xml').documentElement;
        } else if (type === IconTypes.UxIcon) {
            element.content = parser.parseFromString(definition._generated_html, 'image/svg+xml').documentElement;
        } else if (type === IconTypes.Url) {
            const icon = document.createElement('img');
            icon.width = width;
            icon.height = height;
            icon.src = definition.url;
            element.content = icon;
        } else {
            throw new Error(`Unsupported icon type: ${type}.`);
        }
    }

    private closeInfoWindowsExcept(infoWindow: google.maps.InfoWindow) {
        this.infoWindows.forEach((otherInfoWindow) => {
            if (otherInfoWindow !== infoWindow) {
                otherInfoWindow.close();
            }
        });
    }
}
